--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 1b7051a8108fe1fc2df2f1affa65b3719bb578e6
Parents : 3d54d11
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-13T17:37:06-05:00
feat(tests): add comprehensive tests for memory leak fixes, name overwrite handling, and frontend display name protection; refactor auto propagation tests for clarity
Changes
5 files changed, 1411 insertions(+), 8 deletions(-)
Diff
diff --git a/tests/backend/test_auto_propagation.py b/tests/backend/test_auto_propagation.py
index f3ff2045..c7b7271b 100644
--- a/tests/backend/test_auto_propagation.py
+++ b/tests/backend/test_auto_propagation.py
@@ -54,8 +54,8 @@ async def test_auto_propagation_logic():
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
):
- mock_hops.side_effect = (
- lambda dh: 1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
+ mock_hops.side_effect = lambda dh: (
+ 1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
)
await manager.check_and_update_propagation_node()
@@ -79,8 +79,8 @@ async def test_auto_propagation_logic():
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", side_effect=[False, True]),
):
- mock_hops.side_effect = (
- lambda dh: 1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
+ mock_hops.side_effect = lambda dh: (
+ 1 if dh == bytes.fromhex(_VALID_HASH_A) else 3
)
await manager.check_and_update_propagation_node()
@@ -106,8 +106,8 @@ async def test_auto_propagation_logic():
patch.object(manager, "_wait_for_path", return_value=True),
patch.object(manager, "_probe_propagation_sync", return_value=True),
):
- mock_hops.side_effect = (
- lambda dh: 1 if dh == bytes.fromhex(_VALID_HASH_A) else 2
+ mock_hops.side_effect = lambda dh: (
+ 1 if dh == bytes.fromhex(_VALID_HASH_A) else 2
)
await manager.check_and_update_propagation_node()
diff --git a/tests/backend/test_memory_leak_fixes.py b/tests/backend/test_memory_leak_fixes.py
new file mode 100644
index 00000000..9162a448
--- /dev/null
+++ b/tests/backend/test_memory_leak_fixes.py
@@ -0,0 +1,443 @@
+"""Tests for the memory-leak fixes applied to websocket_broadcast,
+update_lxmf_message_state, AsyncUtils.run_async future tracking,
+nomadnet link cache sweeping, and the telemetry warning cap.
+"""
+
+import asyncio
+import gc
+import json
+import secrets
+import threading
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+import RNS
+
+from meshchatx.src.backend.async_utils import AsyncUtils
+from meshchatx.src.backend.database.messages import MessageDAO
+from meshchatx.src.backend.nomadnet_downloader import (
+ _nomadnet_links_lock,
+ nomadnet_cached_links,
+ sweep_stale_links,
+)
+
+
+# ---------------------------------------------------------------------------
+# MessageDAO.update_lxmf_message_state
+# ---------------------------------------------------------------------------
+
+
+class TestUpdateLxmfMessageState:
+ @pytest.fixture
+ def dao(self):
+ provider = MagicMock()
+ return MessageDAO(provider), provider
+
+ def test_update_issues_correct_sql(self, dao):
+ message_dao, provider = dao
+ message_dao.update_lxmf_message_state(
+ message_hash="abc123",
+ state="delivered",
+ progress=100.0,
+ delivery_attempts=3,
+ next_delivery_attempt_at=None,
+ rssi=-55,
+ snr=6.5,
+ quality=4,
+ )
+ provider.execute.assert_called_once()
+ query, params = provider.execute.call_args[0]
+ assert "UPDATE lxmf_messages SET" in query
+ assert "WHERE hash = ?" in query
+ assert params[-1] == "abc123"
+ assert params[0] == "delivered"
+ assert params[1] == 100.0
+ assert params[2] == 3
+
+ def test_update_with_none_optionals(self, dao):
+ message_dao, provider = dao
+ message_dao.update_lxmf_message_state(
+ message_hash="xyz",
+ state="sending",
+ progress=50.0,
+ delivery_attempts=1,
+ next_delivery_attempt_at=None,
+ )
+ _, params = provider.execute.call_args[0]
+ assert params[4] is None # rssi
+ assert params[5] is None # snr
+ assert params[6] is None # quality
+
+ def test_update_does_not_touch_fields_column(self, dao):
+ """The lightweight path must never rewrite the fields/content columns."""
+ message_dao, provider = dao
+ message_dao.update_lxmf_message_state(
+ message_hash="h",
+ state="sent",
+ progress=99.0,
+ delivery_attempts=2,
+ next_delivery_attempt_at=None,
+ )
+ query = provider.execute.call_args[0][0]
+ assert "fields" not in query.lower().split("set")[1].split("where")[0]
+ assert "content" not in query.lower().split("set")[1].split("where")[0]
+
+ def test_full_roundtrip_via_real_db(self, tmp_path):
+ """Insert with upsert, then update state only, verify fields unchanged."""
+ from meshchatx.src.backend.database import Database
+
+ db_path = str(tmp_path / "test.db")
+ db = Database(db_path)
+ db.initialize()
+
+ big_fields = json.dumps({"image": {"image_bytes": "A" * 5000}})
+ msg = {
+ "hash": "deadbeef",
+ "source_hash": "src",
+ "destination_hash": "dst",
+ "peer_hash": "src",
+ "state": "outbound",
+ "progress": 0.0,
+ "is_incoming": 0,
+ "method": "direct",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "hi",
+ "content": "hello",
+ "fields": big_fields,
+ "timestamp": 12345,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ }
+ db.messages.upsert_lxmf_message(msg)
+
+ db.messages.update_lxmf_message_state(
+ message_hash="deadbeef",
+ state="delivered",
+ progress=100.0,
+ delivery_attempts=2,
+ next_delivery_attempt_at=None,
+ rssi=-60,
+ snr=7.0,
+ quality=5,
+ )
+
+ row = db.messages.get_lxmf_message_by_hash("deadbeef")
+ assert row["state"] == "delivered"
+ assert row["progress"] == 100.0
+ assert row["delivery_attempts"] == 2
+ assert row["rssi"] == -60
+ stored_fields = json.loads(row["fields"])
+ assert stored_fields["image"]["image_bytes"] == "A" * 5000
+
+ db.close_all()
+
+
+# ---------------------------------------------------------------------------
+# sweep_stale_links
+# ---------------------------------------------------------------------------
+
+
+class TestSweepStaleLinks:
+ @pytest.fixture(autouse=True)
+ def _clear_cache(self):
+ with _nomadnet_links_lock:
+ nomadnet_cached_links.clear()
+ yield
+ with _nomadnet_links_lock:
+ nomadnet_cached_links.clear()
+
+ def test_sweep_removes_inactive_links(self):
+ active = MagicMock()
+ active.status = RNS.Link.ACTIVE
+ dead = MagicMock()
+ dead.status = RNS.Link.CLOSED
+
+ with _nomadnet_links_lock:
+ nomadnet_cached_links[b"a"] = active
+ nomadnet_cached_links[b"b"] = dead
+
+ sweep_stale_links()
+
+ with _nomadnet_links_lock:
+ assert b"a" in nomadnet_cached_links
+ assert b"b" not in nomadnet_cached_links
+
+ def test_sweep_empty_cache_is_noop(self):
+ sweep_stale_links()
+ with _nomadnet_links_lock:
+ assert len(nomadnet_cached_links) == 0
+
+ def test_sweep_all_active_keeps_all(self):
+ for i in range(5):
+ link = MagicMock()
+ link.status = RNS.Link.ACTIVE
+ with _nomadnet_links_lock:
+ nomadnet_cached_links[bytes([i])] = link
+
+ sweep_stale_links()
+
+ with _nomadnet_links_lock:
+ assert len(nomadnet_cached_links) == 5
+
+ def test_sweep_thread_safety(self):
+ for i in range(20):
+ link = MagicMock()
+ link.status = RNS.Link.CLOSED if i % 2 else RNS.Link.ACTIVE
+ with _nomadnet_links_lock:
+ nomadnet_cached_links[bytes([i])] = link
+
+ errors = []
+
+ def worker():
+ try:
+ sweep_stale_links()
+ except Exception as e:
+ errors.append(e)
+
+ threads = [threading.Thread(target=worker) for _ in range(8)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert not errors
+ with _nomadnet_links_lock:
+ assert len(nomadnet_cached_links) == 10
+
+
+# ---------------------------------------------------------------------------
+# AsyncUtils.run_async future tracking
+# ---------------------------------------------------------------------------
+
+
+class TestAsyncUtilsFutureTracking:
+ """Tests operate directly on the real AsyncUtils class internals,
+ bypassing the conftest global mock for the tracking logic.
+ """
+
+ @pytest.fixture(autouse=True)
+ def _reset(self):
+ AsyncUtils._pending_futures = []
+ yield
+ AsyncUtils._pending_futures = []
+
+ def test_future_appended_on_schedule(self):
+ mock_loop = MagicMock()
+ mock_loop.is_running.return_value = True
+ future = MagicMock()
+ future.done.return_value = False
+
+ real_main_loop = AsyncUtils.main_loop
+ real_run_async = AsyncUtils.run_async.__func__
+
+ try:
+ AsyncUtils.main_loop = mock_loop
+ with patch(
+ "meshchatx.src.backend.async_utils.asyncio.run_coroutine_threadsafe",
+ return_value=future,
+ ):
+ coro = asyncio.coroutine(lambda: None)()
+ real_run_async(coro)
+ finally:
+ AsyncUtils.main_loop = real_main_loop
+
+ assert future in AsyncUtils._pending_futures
+
+ def test_completed_futures_swept_at_threshold(self):
+ done = MagicMock()
+ done.done.return_value = True
+ pending = MagicMock()
+ pending.done.return_value = False
+
+ AsyncUtils._pending_futures = (
+ [done] * (AsyncUtils._FUTURES_SWEEP_THRESHOLD - 2) + [pending]
+ )
+
+ mock_loop = MagicMock()
+ mock_loop.is_running.return_value = True
+ new_future = MagicMock()
+ new_future.done.return_value = False
+
+ real_main_loop = AsyncUtils.main_loop
+ real_run_async = AsyncUtils.run_async.__func__
+
+ try:
+ AsyncUtils.main_loop = mock_loop
+ with patch(
+ "meshchatx.src.backend.async_utils.asyncio.run_coroutine_threadsafe",
+ return_value=new_future,
+ ):
+ coro = asyncio.coroutine(lambda: None)()
+ real_run_async(coro)
+ finally:
+ AsyncUtils.main_loop = real_main_loop
+
+ remaining = AsyncUtils._pending_futures
+ assert done not in remaining
+ assert pending in remaining
+
+ def test_no_loop_prints_warning(self, capsys):
+ real_main_loop = AsyncUtils.main_loop
+ real_run_async = AsyncUtils.run_async.__func__
+ try:
+ AsyncUtils.main_loop = None
+ coro = asyncio.coroutine(lambda: None)()
+ real_run_async(coro)
+ finally:
+ AsyncUtils.main_loop = real_main_loop
+
+ captured = capsys.readouterr()
+ assert "WARNING" in captured.out
+
+
+# ---------------------------------------------------------------------------
+# websocket_broadcast dead-client pruning
+# ---------------------------------------------------------------------------
+
+
+class TestWebsocketBroadcastPruning:
+ @pytest.mark.asyncio
+ async def test_dead_clients_removed_on_send_failure(self):
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ good_ws = AsyncMock()
+ bad_ws = AsyncMock()
+ bad_ws.send_str.side_effect = ConnectionResetError("gone")
+
+ app = MagicMock(spec=ReticulumMeshChat)
+ app.websocket_clients = [good_ws, bad_ws]
+
+ await ReticulumMeshChat.websocket_broadcast(app, '{"type":"ping"}')
+
+ good_ws.send_str.assert_called_once_with('{"type":"ping"}')
+ bad_ws.send_str.assert_called_once()
+ assert bad_ws not in app.websocket_clients
+ assert good_ws in app.websocket_clients
+
+ @pytest.mark.asyncio
+ async def test_all_healthy_clients_kept(self):
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ clients = [AsyncMock() for _ in range(3)]
+ app = MagicMock(spec=ReticulumMeshChat)
+ app.websocket_clients = list(clients)
+
+ await ReticulumMeshChat.websocket_broadcast(app, '{"ok":true}')
+
+ assert len(app.websocket_clients) == 3
+ for c in clients:
+ c.send_str.assert_called_once()
+
+ @pytest.mark.asyncio
+ async def test_all_dead_clients_cleared(self):
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ clients = [AsyncMock() for _ in range(3)]
+ for c in clients:
+ c.send_str.side_effect = BrokenPipeError
+
+ app = MagicMock(spec=ReticulumMeshChat)
+ app.websocket_clients = list(clients)
+
+ await ReticulumMeshChat.websocket_broadcast(app, "data")
+
+ assert len(app.websocket_clients) == 0
+
+
+# ---------------------------------------------------------------------------
+# _telemetry_no_location_warned cap
+# ---------------------------------------------------------------------------
+
+
+class TestTelemetryWarnedCap:
+ def test_cap_at_256_then_cleared(self):
+ warned = set()
+ for i in range(256):
+ if len(warned) >= 256:
+ warned.clear()
+ warned.add(f"hash_{i}")
+
+ assert len(warned) == 256
+
+ if len(warned) >= 256:
+ warned.clear()
+ warned.add("hash_256")
+
+ assert len(warned) == 1
+ assert "hash_256" in warned
+
+
+# ---------------------------------------------------------------------------
+# Integration: state updates don't bloat memory with attachment re-encoding
+# ---------------------------------------------------------------------------
+
+
+class TestStateUpdateMemory:
+ def test_state_update_avoids_large_fields_write(self, tmp_path):
+ """Verify that update_lxmf_message_state does NOT cause the provider
+ to receive the large fields blob that upsert_lxmf_message does."""
+ from meshchatx.src.backend.database import Database
+
+ db = Database(str(tmp_path / "t.db"))
+ db.initialize()
+
+ big_attachment = "X" * 100_000
+ fields = json.dumps({"image": {"image_bytes": big_attachment}})
+ msg = {
+ "hash": "msg1",
+ "source_hash": "s",
+ "destination_hash": "d",
+ "peer_hash": "s",
+ "state": "outbound",
+ "progress": 0.0,
+ "is_incoming": 0,
+ "method": "direct",
+ "delivery_attempts": 0,
+ "next_delivery_attempt_at": None,
+ "title": "",
+ "content": "",
+ "fields": fields,
+ "timestamp": 1,
+ "rssi": None,
+ "snr": None,
+ "quality": None,
+ "is_spam": 0,
+ "reply_to_hash": None,
+ "attachments_stripped": 0,
+ }
+ db.messages.upsert_lxmf_message(msg)
+
+ gc.collect()
+ import psutil, os
+
+ mem_before = psutil.Process(os.getpid()).memory_info().rss
+
+ for _ in range(50):
+ db.messages.update_lxmf_message_state(
+ message_hash="msg1",
+ state="sending",
+ progress=50.0,
+ delivery_attempts=1,
+ next_delivery_attempt_at=None,
+ )
+
+ gc.collect()
+ mem_after = psutil.Process(os.getpid()).memory_info().rss
+ delta_mb = (mem_after - mem_before) / (1024 * 1024)
+
+ assert delta_mb < 5.0, (
+ f"State-only updates grew memory by {delta_mb:.1f} MB; "
+ "they should not re-serialize attachment data"
+ )
+
+ row = db.messages.get_lxmf_message_by_hash("msg1")
+ assert row["state"] == "sending"
+ stored = json.loads(row["fields"])
+ assert stored["image"]["image_bytes"] == big_attachment
+
+ db.close_all()
diff --git a/tests/backend/test_name_overwrite_fixes.py b/tests/backend/test_name_overwrite_fixes.py
new file mode 100644
index 00000000..a2fb0d06
--- /dev/null
+++ b/tests/backend/test_name_overwrite_fixes.py
@@ -0,0 +1,481 @@
+"""Tests for the contact/conversation name overwrite bug fixes.
+
+Covers:
+- Announce upsert preserving app_data via COALESCE when new value is NULL
+- Contacts DAO upsert conflict behaviour
+- Custom display name lifecycle (set, get, delete, re-set)
+- Contact name update propagation
+- Edge cases: empty strings, unicode, very long names, concurrent upserts
+"""
+
+import base64
+import os
+import tempfile
+
+import pytest
+
+from meshchatx.src.backend.database.announces import AnnounceDAO
+from meshchatx.src.backend.database.contacts import ContactsDAO
+from meshchatx.src.backend.database.provider import DatabaseProvider
+from meshchatx.src.backend.database.schema import DatabaseSchema
+from meshchatx.src.backend.meshchat_utils import parse_lxmf_display_name
+
+try:
+ import RNS.vendor.umsgpack as msgpack
+except ImportError:
+ msgpack = None
+
+
+@pytest.fixture
+def temp_db():
+ fd, path = tempfile.mkstemp(suffix=".db")
+ os.close(fd)
+ yield path
+ if os.path.exists(path):
+ os.remove(path)
+
+
+@pytest.fixture
+def provider(temp_db):
+ p = DatabaseProvider(temp_db)
+ schema = DatabaseSchema(p)
+ schema.initialize()
+ yield p
+ p.close()
+
+
+@pytest.fixture
+def announce_dao(provider):
+ return AnnounceDAO(provider)
+
+
+@pytest.fixture
+def contacts_dao(provider):
+ return ContactsDAO(provider)
+
+
+def _make_app_data_b64(name: str) -> str:
+ if msgpack is None:
+ return base64.b64encode(name.encode()).decode()
+ packed = msgpack.packb([name.encode(), None, None])
+ return base64.b64encode(packed).decode()
+
+
+def _base_announce(dest="d" * 32, app_data="some_data"):
+ return {
+ "destination_hash": dest,
+ "aspect": "lxmf.delivery",
+ "identity_hash": "i" * 32,
+ "identity_public_key": "k" * 32,
+ "app_data": app_data,
+ "rssi": -60,
+ "snr": 8,
+ "quality": 0.9,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Announce upsert COALESCE tests
+# ---------------------------------------------------------------------------
+
+
+class TestAnnounceUpsertPreservesAppData:
+ """The core bug: upserting an announce with app_data=None must NOT wipe
+ the previously stored app_data."""
+
+ def test_null_app_data_preserves_existing(self, announce_dao):
+ announce_dao.upsert_announce(_base_announce(app_data="original_name"))
+ row = announce_dao.get_announce_by_hash("d" * 32)
+ assert row["app_data"] == "original_name"
+
+ announce_dao.upsert_announce(_base_announce(app_data=None))
+ row = announce_dao.get_announce_by_hash("d" * 32)
+ assert row["app_data"] == "original_name"
+
+ def test_new_app_data_overwrites_existing(self, announce_dao):
+ announce_dao.upsert_announce(_base_announce(app_data="old_name"))
+ announce_dao.upsert_announce(_base_announce(app_data="new_name"))
+ row = announce_dao.get_announce_by_hash("d" * 32)
+ assert row["app_data"] == "new_name"
+
+ def test_first_insert_with_null_app_data(self, announce_dao):
+ announce_dao.upsert_announce(_base_announce(app_data=None))
+ row = announce_dao.get_announce_by_hash("d" * 32)
+ assert row["app_data"] is None
+
+ def test_first_null_then_real_then_null_again(self, announce_dao):
+ dest = "a" * 32
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
+ row = announce_dao.get_announce_by_hash(dest)
+ assert row["app_data"] is None
+
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data="real_name"))
+ row = announce_dao.get_announce_by_hash(dest)
+ assert row["app_data"] == "real_name"
+
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
+ row = announce_dao.get_announce_by_hash(dest)
+ assert row["app_data"] == "real_name"
+
+ def test_multiple_null_upserts_no_degradation(self, announce_dao):
+ dest = "b" * 32
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data="stable_name"))
+ for _ in range(10):
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
+ row = announce_dao.get_announce_by_hash(dest)
+ assert row["app_data"] == "stable_name"
+
+ def test_other_fields_still_update_on_null_app_data(self, announce_dao):
+ dest = "c" * 32
+ announce_dao.upsert_announce(
+ {**_base_announce(dest=dest, app_data="keep_me"), "rssi": -50}
+ )
+ announce_dao.upsert_announce(
+ {**_base_announce(dest=dest, app_data=None), "rssi": -90}
+ )
+ row = announce_dao.get_announce_by_hash(dest)
+ assert row["app_data"] == "keep_me"
+ assert row["rssi"] == -90
+
+ def test_empty_string_app_data_is_not_null(self, announce_dao):
+ dest = "e" * 32
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data="good_name"))
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data=""))
+ row = announce_dao.get_announce_by_hash(dest)
+ assert row["app_data"] == ""
+
+
+# ---------------------------------------------------------------------------
+# Custom display name lifecycle
+# ---------------------------------------------------------------------------
+
+
+class TestCustomDisplayNameLifecycle:
+ def test_set_and_get(self, announce_dao):
+ announce_dao.upsert_custom_display_name("dest1", "Alice")
+ assert announce_dao.get_custom_display_name("dest1") == "Alice"
+
+ def test_update_overwrites(self, announce_dao):
+ announce_dao.upsert_custom_display_name("dest1", "Alice")
+ announce_dao.upsert_custom_display_name("dest1", "Bob")
+ assert announce_dao.get_custom_display_name("dest1") == "Bob"
+
+ def test_delete_removes(self, announce_dao):
+ announce_dao.upsert_custom_display_name("dest1", "Alice")
+ announce_dao.delete_custom_display_name("dest1")
+ assert announce_dao.get_custom_display_name("dest1") is None
+
+ def test_get_nonexistent_returns_none(self, announce_dao):
+ assert announce_dao.get_custom_display_name("nonexistent") is None
+
+ def test_delete_nonexistent_is_noop(self, announce_dao):
+ announce_dao.delete_custom_display_name("nonexistent")
+
+ def test_set_after_delete(self, announce_dao):
+ announce_dao.upsert_custom_display_name("dest1", "First")
+ announce_dao.delete_custom_display_name("dest1")
+ announce_dao.upsert_custom_display_name("dest1", "Second")
+ assert announce_dao.get_custom_display_name("dest1") == "Second"
+
+ def test_unicode_display_name(self, announce_dao):
+ announce_dao.upsert_custom_display_name("dest1", "\u5c71\u7530\u592a\u90ce")
+ assert (
+ announce_dao.get_custom_display_name("dest1") == "\u5c71\u7530\u592a\u90ce"
+ )
+
+ def test_very_long_display_name(self, announce_dao):
+ long_name = "A" * 10000
+ announce_dao.upsert_custom_display_name("dest1", long_name)
+ assert announce_dao.get_custom_display_name("dest1") == long_name
+
+ def test_empty_string_display_name(self, announce_dao):
+ announce_dao.upsert_custom_display_name("dest1", "")
+ assert announce_dao.get_custom_display_name("dest1") == ""
+
+ def test_multiple_destinations_independent(self, announce_dao):
+ announce_dao.upsert_custom_display_name("dest1", "Alice")
+ announce_dao.upsert_custom_display_name("dest2", "Bob")
+ announce_dao.delete_custom_display_name("dest1")
+ assert announce_dao.get_custom_display_name("dest1") is None
+ assert announce_dao.get_custom_display_name("dest2") == "Bob"
+
+
+# ---------------------------------------------------------------------------
+# Contacts DAO edge cases
+# ---------------------------------------------------------------------------
+
+
+class TestContactsEdgeCases:
+ def test_add_contact_upsert_preserves_addresses(self, contacts_dao, provider):
+ contacts_dao.add_contact(
+ "Alice", "ih1", lxmf_address="lxmf1", lxst_address="lxst1"
+ )
+ contacts_dao.add_contact(
+ "Alice Updated", "ih1", lxmf_address=None, lxst_address=None
+ )
+ row = provider.fetchone(
+ "SELECT * FROM contacts WHERE remote_identity_hash = ?", ("ih1",)
+ )
+ assert row["name"] == "Alice Updated"
+ assert row["lxmf_address"] == "lxmf1"
+ assert row["lxst_address"] == "lxst1"
+
+ def test_add_contact_upsert_replaces_name_unconditionally(
+ self, contacts_dao, provider
+ ):
+ """Verifies add_contact always overwrites name on conflict."""
+ contacts_dao.add_contact("Real Name", "ih2")
+ contacts_dao.add_contact("Overwritten", "ih2")
+ row = provider.fetchone(
+ "SELECT * FROM contacts WHERE remote_identity_hash = ?", ("ih2",)
+ )
+ assert row["name"] == "Overwritten"
+
+ def test_update_contact_partial(self, contacts_dao, provider):
+ contacts_dao.add_contact("Alice", "ih3", lxmf_address="lx3")
+ row = provider.fetchone(
+ "SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih3",)
+ )
+ cid = row["id"]
+ contacts_dao.update_contact(cid, name="Alice Renamed")
+ updated = provider.fetchone("SELECT * FROM contacts WHERE id = ?", (cid,))
+ assert updated["name"] == "Alice Renamed"
+ assert updated["lxmf_address"] == "lx3"
+
+ def test_update_contact_no_fields_is_noop(self, contacts_dao, provider):
+ contacts_dao.add_contact("NoOp", "ih4")
+ row = provider.fetchone(
+ "SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih4",)
+ )
+ contacts_dao.update_contact(row["id"])
+ updated = provider.fetchone("SELECT * FROM contacts WHERE id = ?", (row["id"],))
+ assert updated["name"] == "NoOp"
+
+ def test_update_contact_clear_image(self, contacts_dao, provider):
+ contacts_dao.add_contact(
+ "WithImage", "ih5", custom_image="data:image/png;base64,abc"
+ )
+ row = provider.fetchone(
+ "SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih5",)
+ )
+ assert row is not None
+ cid = row["id"]
+ contacts_dao.update_contact(cid, clear_image=True)
+ updated = provider.fetchone("SELECT * FROM contacts WHERE id = ?", (cid,))
+ assert updated["custom_image"] is None
+
+ def test_get_contact_by_identity_hash_matches_lxmf_address(self, contacts_dao):
+ contacts_dao.add_contact("Via LXMF", "ih6", lxmf_address="lxmf6")
+ result = contacts_dao.get_contact_by_identity_hash("lxmf6")
+ assert result is not None
+ assert result["name"] == "Via LXMF"
+
+ def test_get_contact_by_identity_hash_matches_lxst_address(self, contacts_dao):
+ contacts_dao.add_contact("Via LXST", "ih7", lxst_address="lxst7")
+ result = contacts_dao.get_contact_by_identity_hash("lxst7")
+ assert result is not None
+ assert result["name"] == "Via LXST"
+
+ def test_delete_nonexistent_contact(self, contacts_dao):
+ contacts_dao.delete_contact(99999)
+
+ def test_unicode_contact_name(self, contacts_dao, provider):
+ contacts_dao.add_contact("\u00c9milie \u00d6sterreich", "ih8")
+ row = provider.fetchone(
+ "SELECT * FROM contacts WHERE remote_identity_hash = ?", ("ih8",)
+ )
+ assert row["name"] == "\u00c9milie \u00d6sterreich"
+
+ def test_contacts_search_case_insensitive_name(self, contacts_dao):
+ contacts_dao.add_contact("Charlie Delta", "ih9")
+ results = contacts_dao.get_contacts(search="charlie")
+ assert len(results) >= 1
+ assert any(r["name"] == "Charlie Delta" for r in results)
+
+ def test_contacts_count_with_search(self, contacts_dao):
+ contacts_dao.add_contact("Alpha", "ih10")
+ contacts_dao.add_contact("Beta", "ih11")
+ contacts_dao.add_contact("AlphaTwo", "ih12")
+ assert contacts_dao.get_contacts_count(search="alpha") == 2
+ assert contacts_dao.get_contacts_count() == 3
+
+
+# ---------------------------------------------------------------------------
+# parse_lxmf_display_name fallback behaviour
+# ---------------------------------------------------------------------------
+
+
+class TestParseLxmfDisplayNameFallback:
+ def test_none_returns_anonymous_peer(self):
+ assert parse_lxmf_display_name(None) == "Anonymous Peer"
+
+ def test_none_with_custom_default(self):
+ assert parse_lxmf_display_name(None, default_value="Fallback") == "Fallback"
+
+ def test_none_with_none_default(self):
+ assert parse_lxmf_display_name(None, default_value=None) is None
+
+ @pytest.mark.skipif(msgpack is None, reason="msgpack not available")
+ def test_valid_app_data_returns_name(self):
+ name = "TestNode"
+ packed = msgpack.packb([name.encode(), None, None])
+ b64 = base64.b64encode(packed).decode()
+ assert parse_lxmf_display_name(b64) == name
+
+ @pytest.mark.skipif(msgpack is None, reason="msgpack not available")
+ def test_empty_name_in_app_data(self):
+ packed = msgpack.packb([b"", None, None])
+ b64 = base64.b64encode(packed).decode()
+ result = parse_lxmf_display_name(b64)
+ assert result == ""
+
+ def test_garbage_base64(self):
+ result = parse_lxmf_display_name("!!!not-base64!!!")
+ assert isinstance(result, str)
+
+ @pytest.mark.skipif(msgpack is None, reason="msgpack not available")
+ def test_none_name_in_app_data_returns_default(self):
+ packed = msgpack.packb([None, None, None])
+ b64 = base64.b64encode(packed).decode()
+ assert parse_lxmf_display_name(b64) == "Anonymous Peer"
+
+
+# ---------------------------------------------------------------------------
+# Integration: announce + custom name + contact name resolution priority
+# ---------------------------------------------------------------------------
+
+
+class TestNameResolutionPriority:
+ """Simulates the priority chain:
+ custom_display_name > announce app_data > contact name > 'Anonymous Peer'
+ """
+
+ def test_custom_name_wins_over_announce(self, announce_dao):
+ dest = "f" * 32
+ announce_dao.upsert_announce(
+ _base_announce(dest=dest, app_data="AnnounceAlice")
+ )
+ announce_dao.upsert_custom_display_name(dest, "CustomAlice")
+
+ custom = announce_dao.get_custom_display_name(dest)
+ row = announce_dao.get_announce_by_hash(dest)
+ display = custom or row["app_data"] or "Anonymous Peer"
+ assert display == "CustomAlice"
+
+ def test_announce_used_when_no_custom(self, announce_dao):
+ dest = "f" * 32
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data="AnnounceOnly"))
+
+ custom = announce_dao.get_custom_display_name(dest)
+ row = announce_dao.get_announce_by_hash(dest)
+ display = custom or row["app_data"] or "Anonymous Peer"
+ assert display == "AnnounceOnly"
+
+ def test_fallback_to_anonymous_when_nothing(self, announce_dao):
+ dest = "f" * 32
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
+
+ custom = announce_dao.get_custom_display_name(dest)
+ row = announce_dao.get_announce_by_hash(dest)
+ display = custom or row["app_data"] or "Anonymous Peer"
+ assert display == "Anonymous Peer"
+
+ def test_contact_name_used_when_no_announce_no_custom(
+ self, announce_dao, contacts_dao, provider
+ ):
+ dest = "f" * 32
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
+ contacts_dao.add_contact("ContactAlice", dest, lxmf_address=dest)
+
+ custom = announce_dao.get_custom_display_name(dest)
+ row = announce_dao.get_announce_by_hash(dest)
+ contact = contacts_dao.get_contact_by_identity_hash(dest)
+ contact_name = contact["name"] if contact else None
+ display = custom or row["app_data"] or contact_name or "Anonymous Peer"
+ assert display == "ContactAlice"
+
+ def test_clearing_custom_name_falls_back_to_announce(self, announce_dao):
+ dest = "f" * 32
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data="AnnounceName"))
+ announce_dao.upsert_custom_display_name(dest, "Custom")
+ announce_dao.delete_custom_display_name(dest)
+
+ custom = announce_dao.get_custom_display_name(dest)
+ row = announce_dao.get_announce_by_hash(dest)
+ display = custom or row["app_data"] or "Anonymous Peer"
+ assert display == "AnnounceName"
+
+ def test_wiping_announce_with_contact_still_resolves(
+ self, announce_dao, contacts_dao
+ ):
+ """After the COALESCE fix, this should not happen, but if app_data
+ was already NULL, the contact name should still be available."""
+ dest = "f" * 32
+ contacts_dao.add_contact("ContactFallback", dest, lxmf_address=dest)
+ announce_dao.upsert_announce(_base_announce(dest=dest, app_data=None))
+
+ custom = announce_dao.get_custom_display_name(dest)
+ row = announce_dao.get_announce_by_hash(dest)
+ contact = contacts_dao.get_contact_by_identity_hash(dest)
+ contact_name = contact["name"] if contact else None
+ display = custom or row["app_data"] or contact_name or "Anonymous Peer"
+ assert display == "ContactFallback"
+
+
+# ---------------------------------------------------------------------------
+# Announce trim does not break name resolution
+# ---------------------------------------------------------------------------
+
+
+class TestAnnounceTrimSafety:
+ def test_trim_does_not_remove_active_announce(self, announce_dao):
+ for i in range(5):
+ announce_dao.upsert_announce(
+ _base_announce(dest=f"{i:032x}", app_data=f"name_{i}")
+ )
+ announce_dao.trim_announces_for_aspect("lxmf.delivery", max_rows=3)
+ remaining = announce_dao.get_announces(aspect="lxmf.delivery")
+ assert len(remaining) == 3
+ for r in remaining:
+ assert r["app_data"] is not None
+ assert r["app_data"].startswith("name_")
+
+
+# ---------------------------------------------------------------------------
+# Contacts + custom display name sync edge cases
+# ---------------------------------------------------------------------------
+
+
+class TestContactCustomNameSync:
+ def test_renaming_contact_and_custom_name_independently(
+ self, contacts_dao, announce_dao, provider
+ ):
+ contacts_dao.add_contact("Alice", "ih20", lxmf_address="lx20")
+ row = provider.fetchone(
+ "SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih20",)
+ )
+ cid = row["id"]
+
+ announce_dao.upsert_custom_display_name("lx20", "Alice Custom")
+ contacts_dao.update_contact(cid, name="Alice Renamed")
+
+ assert announce_dao.get_custom_display_name("lx20") == "Alice Custom"
+ contact = contacts_dao.get_contact(cid)
+ assert contact["name"] == "Alice Renamed"
+
+ def test_synced_rename_updates_both(self, contacts_dao, announce_dao, provider):
+ """Simulates what the UI should do: update both contact and custom name."""
+ contacts_dao.add_contact("Bob", "ih21", lxmf_address="lx21")
+ row = provider.fetchone(
+ "SELECT id FROM contacts WHERE remote_identity_hash = ?", ("ih21",)
+ )
+ cid = row["id"]
+
+ new_name = "Bob Renamed"
+ contacts_dao.update_contact(cid, name=new_name)
+ announce_dao.upsert_custom_display_name("lx21", new_name)
+
+ contact = contacts_dao.get_contact(cid)
+ custom = announce_dao.get_custom_display_name("lx21")
+ assert contact["name"] == new_name
+ assert custom == new_name
diff --git a/tests/frontend/NameOverwriteFixes.test.js b/tests/frontend/NameOverwriteFixes.test.js
new file mode 100644
index 00000000..80b716ad
--- /dev/null
+++ b/tests/frontend/NameOverwriteFixes.test.js
@@ -0,0 +1,478 @@
+import { mount } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import MessagesPage from "@/components/messages/MessagesPage.vue";
+import ContactsPage from "@/components/contacts/ContactsPage.vue";
+import DialogUtils from "@/js/DialogUtils";
+
+vi.mock("@/js/DialogUtils", () => ({
+ default: {
+ prompt: vi.fn(),
+ alert: vi.fn(),
+ confirm: vi.fn(() => Promise.resolve(true)),
+ },
+}));
+
+vi.mock("@/js/WebSocketConnection", () => ({
+ default: {
+ on: vi.fn(),
+ off: vi.fn(),
+ send: vi.fn(),
+ },
+}));
+
+vi.mock("qrcode", () => ({
+ default: {
+ toDataURL: vi.fn().mockResolvedValue("data:image/png;base64,test"),
+ },
+}));
+
+describe("MessagesPage display name protection", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ axiosMock = {
+ get: vi.fn(),
+ post: vi.fn(),
+ };
+ window.api = axiosMock;
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config")
+ return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
+ if (url === "/api/v1/lxmf/conversations") return Promise.resolve({ data: { conversations: [] } });
+ if (url === "/api/v1/announces") return Promise.resolve({ data: { announces: [] } });
+ if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
+ return Promise.resolve({ data: {} });
+ });
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ const mountMessagesPage = (props = { destinationHash: "" }) => {
+ return mount(MessagesPage, {
+ props,
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $route: { query: {} },
+ $router: { replace: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: true,
+ LoadingSpinner: true,
+ MessagesSidebar: {
+ template: '<div class="sidebar-stub"></div>',
+ props: ["conversations", "selectedDestinationHash"],
+ },
+ ConversationViewer: {
+ template: '<div class="viewer-stub"></div>',
+ props: ["selectedPeer", "myLxmfAddressHash"],
+ },
+ Modal: true,
+ },
+ },
+ });
+ };
+
+ it("does not overwrite a known display name with Anonymous Peer from announce", async () => {
+ const destHash = "a".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.peers[destHash] = {
+ destination_hash: destHash,
+ display_name: "Real Name",
+ custom_display_name: null,
+ };
+
+ wrapper.vm.updatePeerFromAnnounce({
+ destination_hash: destHash,
+ display_name: "Anonymous Peer",
+ });
+
+ expect(wrapper.vm.peers[destHash].display_name).toBe("Real Name");
+ });
+
+ it("allows overwriting Anonymous Peer with a real name from announce", async () => {
+ const destHash = "b".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.peers[destHash] = {
+ destination_hash: destHash,
+ display_name: "Anonymous Peer",
+ };
+
+ wrapper.vm.updatePeerFromAnnounce({
+ destination_hash: destHash,
+ display_name: "Newly Announced",
+ });
+
+ expect(wrapper.vm.peers[destHash].display_name).toBe("Newly Announced");
+ });
+
+ it("allows updating from one real name to another real name via announce", async () => {
+ const destHash = "c".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.peers[destHash] = {
+ destination_hash: destHash,
+ display_name: "Old Name",
+ };
+
+ wrapper.vm.updatePeerFromAnnounce({
+ destination_hash: destHash,
+ display_name: "New Name",
+ });
+
+ expect(wrapper.vm.peers[destHash].display_name).toBe("New Name");
+ });
+
+ it("preserves known name when conversation list returns Anonymous Peer", async () => {
+ const destHash = "d".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.peers[destHash] = {
+ destination_hash: destHash,
+ display_name: "Known Peer",
+ custom_display_name: null,
+ };
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/lxmf/conversations")
+ return Promise.resolve({
+ data: {
+ conversations: [
+ {
+ destination_hash: destHash,
+ display_name: "Anonymous Peer",
+ custom_display_name: null,
+ },
+ ],
+ },
+ });
+ if (url === "/api/v1/config")
+ return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
+ if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
+ return Promise.resolve({ data: {} });
+ });
+
+ await wrapper.vm.getConversations();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.peers[destHash].display_name).toBe("Known Peer");
+ });
+
+ it("accepts new display name from conversation list when peer was Anonymous", async () => {
+ const destHash = "e".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.peers[destHash] = {
+ destination_hash: destHash,
+ display_name: "Anonymous Peer",
+ };
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/lxmf/conversations")
+ return Promise.resolve({
+ data: {
+ conversations: [
+ {
+ destination_hash: destHash,
+ display_name: "Resolved Name",
+ custom_display_name: null,
+ },
+ ],
+ },
+ });
+ if (url === "/api/v1/config")
+ return Promise.resolve({ data: { config: { lxmf_address_hash: "my-hash" } } });
+ if (url === "/api/v1/lxmf/conversation-pins") return Promise.resolve({ data: { peer_hashes: [] } });
+ return Promise.resolve({ data: {} });
+ });
+
+ await wrapper.vm.getConversations();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.peers[destHash].display_name).toBe("Resolved Name");
+ });
+
+ it("does not overwrite name via resolvePeerDisplayName when server returns Anonymous", async () => {
+ const destHash = "f".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.conversations = [
+ { destination_hash: destHash, display_name: "Original Name", custom_display_name: null },
+ ];
+ wrapper.vm.selectedPeer = { destination_hash: destHash, display_name: "Original Name" };
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/lxmf/conversations")
+ return Promise.resolve({
+ data: {
+ conversations: [
+ {
+ destination_hash: destHash,
+ display_name: "Anonymous Peer",
+ custom_display_name: null,
+ },
+ ],
+ },
+ });
+ return Promise.resolve({ data: {} });
+ });
+
+ await wrapper.vm.resolvePeerDisplayName(destHash);
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.conversations[0].display_name).toBe("Original Name");
+ expect(wrapper.vm.selectedPeer.display_name).toBe("Original Name");
+ });
+
+ it("handles new peer with no prior entry gracefully", async () => {
+ const destHash = "1".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.updatePeerFromAnnounce({
+ destination_hash: destHash,
+ display_name: "Anonymous Peer",
+ });
+
+ expect(wrapper.vm.peers[destHash].display_name).toBe("Anonymous Peer");
+ });
+
+ it("preserves custom_display_name when announce does not carry it", async () => {
+ const destHash = "2".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.peers[destHash] = {
+ destination_hash: destHash,
+ display_name: "Announced Name",
+ custom_display_name: "My Custom Name",
+ };
+
+ wrapper.vm.updatePeerFromAnnounce({
+ destination_hash: destHash,
+ display_name: "Anonymous Peer",
+ });
+
+ expect(wrapper.vm.peers[destHash].custom_display_name).toBe("My Custom Name");
+ expect(wrapper.vm.peers[destHash].display_name).toBe("Announced Name");
+ });
+
+ it("server-provided custom_display_name in announce is authoritative", async () => {
+ const destHash = "3".repeat(32);
+ const wrapper = mountMessagesPage();
+ await wrapper.vm.$nextTick();
+
+ wrapper.vm.peers[destHash] = {
+ destination_hash: destHash,
+ display_name: "Old",
+ custom_display_name: "Stale Custom",
+ };
+
+ wrapper.vm.updatePeerFromAnnounce({
+ destination_hash: destHash,
+ display_name: "Anonymous Peer",
+ custom_display_name: "Server Custom",
+ });
+
+ expect(wrapper.vm.peers[destHash].custom_display_name).toBe("Server Custom");
+ expect(wrapper.vm.peers[destHash].display_name).toBe("Old");
+ });
+});
+
+describe("ContactsPage edit contact name", () => {
+ let axiosMock;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ axiosMock = {
+ get: vi.fn(),
+ post: vi.fn(),
+ patch: vi.fn(),
+ delete: vi.fn(),
+ };
+ window.api = axiosMock;
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/config")
+ return Promise.resolve({
+ data: {
+ config: {
+ lxmf_address_hash: "a".repeat(32),
+ identity_public_key: "b".repeat(128),
+ },
+ },
+ });
+ if (url === "/api/v1/telephone/contacts")
+ return Promise.resolve({ data: { contacts: [], total_count: 0 } });
+ return Promise.resolve({ data: {} });
+ });
+
+ axiosMock.patch.mockResolvedValue({ data: { message: "Contact updated" } });
+ axiosMock.post.mockResolvedValue({ data: { message: "OK" } });
+ });
+
+ afterEach(() => {
+ delete window.api;
+ });
+
+ const mountPage = () =>
+ mount(ContactsPage, {
+ global: {
+ mocks: {
+ $t: (key) => key,
+ $router: { push: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: true,
+ LxmfUserIcon: true,
+ },
+ },
+ });
+
+ it("editContactName updates both contact and custom display name", async () => {
+ DialogUtils.prompt.mockResolvedValue("Renamed Alice");
+
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+
+ const contact = {
+ id: 42,
+ name: "Alice",
+ remote_identity_hash: "a".repeat(32),
+ lxmf_address: "a".repeat(32),
+ remote_destination_hash: "a".repeat(32),
+ };
+
+ await wrapper.vm.editContactName(contact);
+
+ expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/42", {
+ name: "Renamed Alice",
+ });
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ `/api/v1/destination/${"a".repeat(32)}/custom-display-name/update`,
+ { display_name: "Renamed Alice" }
+ );
+ });
+
+ it("editContactName does nothing when user cancels prompt", async () => {
+ DialogUtils.prompt.mockResolvedValue(null);
+
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.editContactName({ id: 1, name: "Alice" });
+
+ expect(axiosMock.patch).not.toHaveBeenCalled();
+ expect(axiosMock.post).not.toHaveBeenCalled();
+ });
+
+ it("editContactName does nothing when name unchanged", async () => {
+ DialogUtils.prompt.mockResolvedValue("Alice");
+
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.editContactName({ id: 1, name: "Alice" });
+
+ expect(axiosMock.patch).not.toHaveBeenCalled();
+ });
+
+ it("editContactName skips custom display name when no dest hash", async () => {
+ DialogUtils.prompt.mockResolvedValue("New Name");
+
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+
+ const contact = {
+ id: 99,
+ name: "Old Name",
+ remote_identity_hash: null,
+ lxmf_address: null,
+ };
+
+ await wrapper.vm.editContactName(contact);
+
+ expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/99", {
+ name: "New Name",
+ });
+ expect(axiosMock.post).not.toHaveBeenCalled();
+ });
+
+ it("editContactName uses lxmf_address when remote_destination_hash absent", async () => {
+ DialogUtils.prompt.mockResolvedValue("Updated");
+
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+
+ const contact = {
+ id: 10,
+ name: "Old",
+ remote_identity_hash: "i".repeat(32),
+ lxmf_address: "l".repeat(32),
+ };
+
+ await wrapper.vm.editContactName(contact);
+
+ expect(axiosMock.post).toHaveBeenCalledWith(
+ `/api/v1/destination/${"l".repeat(32)}/custom-display-name/update`,
+ { display_name: "Updated" }
+ );
+ });
+
+ it("editContactName handles API error gracefully", async () => {
+ DialogUtils.prompt.mockResolvedValue("Fail Name");
+ axiosMock.patch.mockRejectedValue(new Error("Network error"));
+
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+
+ const contact = { id: 5, name: "Before", remote_identity_hash: "x".repeat(32) };
+
+ await wrapper.vm.editContactName(contact);
+
+ expect(axiosMock.patch).toHaveBeenCalled();
+ });
+
+ it("editContactName with empty string still calls patch but not custom display name", async () => {
+ DialogUtils.prompt.mockResolvedValue("");
+
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+
+ const contact = {
+ id: 7,
+ name: "Was Something",
+ remote_identity_hash: "r".repeat(32),
+ lxmf_address: "r".repeat(32),
+ };
+
+ await wrapper.vm.editContactName(contact);
+
+ expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/telephone/contacts/7", {
+ name: "",
+ });
+ expect(axiosMock.post).not.toHaveBeenCalled();
+ });
+
+ it("editContactName with no contact id does nothing", async () => {
+ const wrapper = mountPage();
+ await wrapper.vm.$nextTick();
+
+ await wrapper.vm.editContactName({});
+
+ expect(DialogUtils.prompt).not.toHaveBeenCalled();
+ expect(axiosMock.patch).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js
index af51ae5e..85516fcd 100644
--- a/tests/frontend/ToolsPage.test.js
+++ b/tests/frontend/ToolsPage.test.js
@@ -17,6 +17,7 @@ describe("ToolsPage.vue", () => {
{ path: "/bots", name: "bots", component: { template: "div" } },
{ path: "/forwarder", name: "forwarder", component: { template: "div" } },
{ path: "/documentation", name: "documentation", component: { template: "div" } },
+ { path: "/licenses", name: "licenses", component: { template: "div" } },
{ path: "/micron-editor", name: "micron-editor", component: { template: "div" } },
{ path: "/paper-message", name: "paper-message", component: { template: "div" } },
{ path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } },
@@ -51,7 +52,7 @@ describe("ToolsPage.vue", () => {
it("renders all tool rows", () => {
const wrapper = mountToolsPage();
const toolRows = wrapper.findAll(".tool-row");
- expect(toolRows.length).toBe(17);
+ expect(toolRows.length).toBe(18);
});
it("filters tools based on search query", async () => {
@@ -76,6 +77,6 @@ describe("ToolsPage.vue", () => {
await clearButton.trigger("click");
expect(wrapper.vm.searchQuery).toBe("");
- expect(wrapper.vm.filteredTools.length).toBe(17);
+ expect(wrapper.vm.filteredTools.length).toBe(18);
});
});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────